--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit ef7f42c190120a5fa884acb5595ea8dcc93b3aaa
Parents : b7afdad
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-29T18:27:59-05:00
feat(tests): add comprehensive security and robustness tests for archives and deep links
Changes
8 files changed, 668 insertions(+), 58 deletions(-)
Diff
diff --git a/tests/backend/test_archives_api_robustness.py b/tests/backend/test_archives_api_robustness.py
new file mode 100644
index 00000000..38f28c59
--- /dev/null
+++ b/tests/backend/test_archives_api_robustness.py
@@ -0,0 +1,167 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Robustness tests for REST handlers (archives, bundled docs, lxmf send) and related WS paths."""
+
+import json
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from hypothesis import HealthCheck, given, settings
+from hypothesis import strategies as st
+
+
+def _handler(app, method: str, path: str):
+ for route in app.get_routes():
+ if route.method == method and route.path == path:
+ return route.handler
+ return None
+
+
+@pytest.mark.asyncio
+async def test_get_archives_returns_json_for_edge_case_queries(mock_app):
+ handler = _handler(mock_app, "GET", "/api/v1/nomadnet/archives")
+ assert handler is not None
+
+ edge_queries = [
+ {},
+ {"q": "", "page": "1", "limit": "100"},
+ {"q": "%00%3Cscript%3E", "page": "not-a-number", "limit": "not-a-number"},
+ {"page": "-5", "limit": "0"},
+ {"limit": "99999"},
+ ]
+ for query in edge_queries:
+ request = MagicMock()
+ request.query = query
+ response = await handler(request)
+ assert response.status == 200
+ payload = json.loads(response.body)
+ assert isinstance(payload.get("archives"), list)
+ assert "pagination" in payload
+
+
+@settings(
+ max_examples=60,
+ suppress_health_check=[HealthCheck.function_scoped_fixture],
+ deadline=None,
+)
+@given(
+ q=st.text(max_size=800),
+ page=st.one_of(
+ st.integers(min_value=-1000, max_value=10**9),
+ st.text(max_size=40),
+ ),
+ limit=st.one_of(
+ st.integers(min_value=-1000, max_value=10**9),
+ st.text(max_size=40),
+ ),
+)
+@pytest.mark.asyncio
+async def test_get_archives_query_fuzz(mock_app, q, page, limit):
+ handler = _handler(mock_app, "GET", "/api/v1/nomadnet/archives")
+ assert handler is not None
+
+ request = MagicMock()
+ request.query = {
+ "q": q,
+ "page": str(page),
+ "limit": str(limit),
+ }
+ response = await handler(request)
+ assert response.status == 200
+ payload = json.loads(response.body)
+ assert isinstance(payload.get("archives"), list)
+ assert "pagination" in payload
+
+
+@pytest.mark.asyncio
+async def test_delete_archives_requires_nonempty_ids(mock_app):
+ handler = _handler(mock_app, "DELETE", "/api/v1/nomadnet/archives")
+ assert handler is not None
+
+ request = MagicMock()
+ request.json = AsyncMock(return_value={"ids": []})
+ response = await handler(request)
+ assert response.status == 400
+
+
+@pytest.mark.asyncio
+async def test_meshchatx_docs_content_requires_path(mock_app):
+ handler = _handler(mock_app, "GET", "/api/v1/meshchatx-docs/content")
+ assert handler is not None
+ request = MagicMock()
+ request.query = {}
+ response = await handler(request)
+ assert response.status == 400
+
+
+@settings(
+ max_examples=40,
+ suppress_health_check=[HealthCheck.function_scoped_fixture],
+ deadline=None,
+)
+@given(path=st.text(min_size=1, max_size=600))
+@pytest.mark.asyncio
+async def test_meshchatx_docs_content_path_fuzz(mock_app, path):
+ handler = _handler(mock_app, "GET", "/api/v1/meshchatx-docs/content")
+ assert handler is not None
+ request = MagicMock()
+ request.query = {"path": path}
+ response = await handler(request)
+ assert response.status in (200, 404)
+ json.loads(response.body)
+
+
+@pytest.mark.asyncio
+async def test_lxmf_messages_send_requires_lxmf_message(mock_app):
+ handler = _handler(mock_app, "POST", "/api/v1/lxmf-messages/send")
+ assert handler is not None
+ request = MagicMock()
+ request.json = AsyncMock(return_value={})
+ response = await handler(request)
+ assert response.status == 400
+
+
+@pytest.mark.asyncio
+async def test_lxmf_messages_send_requires_destination_and_content(mock_app):
+ handler = _handler(mock_app, "POST", "/api/v1/lxmf-messages/send")
+ assert handler is not None
+ request = MagicMock()
+ request.json = AsyncMock(
+ return_value={"lxmf_message": {"fields": {}}},
+ )
+ response = await handler(request)
+ assert response.status == 400
+
+
+@pytest.mark.asyncio
+async def test_lxmf_messages_send_rejects_bad_image_field(mock_app):
+ handler = _handler(mock_app, "POST", "/api/v1/lxmf-messages/send")
+ assert handler is not None
+ request = MagicMock()
+ request.json = AsyncMock(
+ return_value={
+ "lxmf_message": {
+ "destination_hash": "aa",
+ "content": "x",
+ "fields": {
+ "image": {"image_type": "png", "image_bytes": "@@@not-base64@@@"},
+ },
+ },
+ },
+ )
+ response = await handler(request)
+ assert response.status == 400
+
+
+@pytest.mark.asyncio
+async def test_nomadnet_file_download_invalid_hex_does_not_raise(mock_app):
+ await mock_app.on_websocket_data_received(
+ MagicMock(),
+ {
+ "type": "nomadnet.file.download",
+ "nomadnet_file_download": {
+ "destination_hash": "gg",
+ "file_path": "/file/x.txt",
+ },
+ },
+ )
diff --git a/tests/backend/test_deep_links_security.py b/tests/backend/test_deep_links_security.py
index 756d40d7..0cbabcde 100644
--- a/tests/backend/test_deep_links_security.py
+++ b/tests/backend/test_deep_links_security.py
@@ -272,6 +272,169 @@ def test_meshchatx_map_numeric_params_fuzzing(mock_app, lat, lon, z, extra):
assert 0 <= mq["zoom"] <= 22
+@pytest.mark.asyncio
+async def test_lxm_ingest_docs_uri_with_reticulum(mock_app):
+ mock_client = MagicMock()
+ mock_client.send_str = MagicMock(return_value=asyncio.sleep(0))
+ mock_app.message_router.ingest_lxm_uri = MagicMock()
+
+ with patch(
+ "meshchatx.meshchat.AsyncUtils.run_async",
+ side_effect=lambda coro: asyncio.create_task(coro),
+ ):
+ await mock_app.on_websocket_data_received(
+ mock_client,
+ {
+ "type": "lxm.ingest_uri",
+ "uri": "meshchatx://docs?reticulum=manual/interfaces.html%23section",
+ },
+ )
+ await asyncio.sleep(0)
+
+ mock_app.message_router.ingest_lxm_uri.assert_not_called()
+ payload = json.loads(mock_client.send_str.call_args[0][0])
+ assert payload["type"] == "lxm.ingest_uri.result"
+ assert payload["status"] == "success"
+ assert payload["ingest_type"] == "docs_view"
+ assert payload["docs_query"]["reticulum"] == "manual/interfaces.html#section"
+
+
+@pytest.mark.asyncio
+async def test_lxm_ingest_docs_uri_meshchat_alias(mock_app):
+ mock_client = MagicMock()
+ mock_client.send_str = MagicMock(return_value=asyncio.sleep(0))
+ mock_app.message_router.ingest_lxm_uri = MagicMock()
+
+ with patch(
+ "meshchatx.meshchat.AsyncUtils.run_async",
+ side_effect=lambda coro: asyncio.create_task(coro),
+ ):
+ await mock_app.on_websocket_data_received(
+ mock_client,
+ {"type": "lxm.ingest_uri", "uri": "meshchat://docs?path=manual/index.html"},
+ )
+ await asyncio.sleep(0)
+
+ payload = json.loads(mock_client.send_str.call_args[0][0])
+ assert payload["ingest_type"] == "docs_view"
+ assert payload["docs_query"]["reticulum"] == "manual/index.html"
+
+
+@pytest.mark.asyncio
+async def test_lxm_ingest_docs_uri_open_index(mock_app):
+ mock_client = MagicMock()
+ mock_client.send_str = MagicMock(return_value=asyncio.sleep(0))
+ mock_app.message_router.ingest_lxm_uri = MagicMock()
+
+ with patch(
+ "meshchatx.meshchat.AsyncUtils.run_async",
+ side_effect=lambda coro: asyncio.create_task(coro),
+ ):
+ await mock_app.on_websocket_data_received(
+ mock_client,
+ {"type": "lxm.ingest_uri", "uri": "meshchatx://docs"},
+ )
+ await asyncio.sleep(0)
+
+ payload = json.loads(mock_client.send_str.call_args[0][0])
+ assert payload["ingest_type"] == "docs_view"
+ assert "docs_query" not in payload
+
+
+@pytest.mark.asyncio
+async def test_lxm_ingest_docs_hostname_spoof_not_docs_view(mock_app):
+ mock_client = MagicMock()
+ mock_client.send_str = MagicMock(return_value=asyncio.sleep(0))
+ mock_app.message_router.ingest_lxm_uri = MagicMock()
+
+ with patch(
+ "meshchatx.meshchat.AsyncUtils.run_async",
+ side_effect=lambda coro: asyncio.create_task(coro),
+ ):
+ await mock_app.on_websocket_data_received(
+ mock_client,
+ {"type": "lxm.ingest_uri", "uri": "meshchatx://docs-foo?reticulum=evil"},
+ )
+ await asyncio.sleep(0)
+
+ payload = json.loads(mock_client.send_str.call_args[0][0])
+ assert payload.get("ingest_type") != "docs_view"
+ mock_app.message_router.ingest_lxm_uri.assert_called()
+
+
+@pytest.mark.parametrize(
+ "rel",
+ [
+ "<script>alert(1)</script>",
+ "'; DROP TABLE docs;--",
+ "a" * 8000,
+ ],
+)
+@pytest.mark.asyncio
+async def test_lxm_ingest_docs_opaque_reticulum_roundtrip(mock_app, rel):
+ mock_client = MagicMock()
+ mock_client.send_str = MagicMock(return_value=asyncio.sleep(0))
+ mock_app.message_router.ingest_lxm_uri = MagicMock()
+ q = urlencode({"reticulum": rel}, quote_via=quote)
+ uri = f"meshchatx://docs?{q}"
+
+ with patch(
+ "meshchatx.meshchat.AsyncUtils.run_async",
+ side_effect=lambda coro: asyncio.create_task(coro),
+ ):
+ await mock_app.on_websocket_data_received(
+ mock_client,
+ {"type": "lxm.ingest_uri", "uri": uri},
+ )
+ await asyncio.sleep(0)
+
+ payload = json.loads(mock_client.send_str.call_args[0][0])
+ assert payload["ingest_type"] == "docs_view"
+ assert payload["docs_query"]["reticulum"] == rel
+
+
+@settings(
+ suppress_health_check=[HealthCheck.function_scoped_fixture],
+ deadline=None,
+ max_examples=80,
+)
+@given(tail=st.text(max_size=800))
+def test_meshchatx_docs_query_tail_fuzzing(mock_app, tail):
+ uri = "meshchatx://docs?" + tail
+ mock_client = MagicMock()
+ mock_client.send_str = MagicMock(return_value=asyncio.sleep(0))
+ mock_app.message_router.ingest_lxm_uri = MagicMock()
+
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ try:
+
+ async def _run():
+ with patch(
+ "meshchatx.meshchat.AsyncUtils.run_async",
+ side_effect=lambda coro: asyncio.create_task(coro),
+ ):
+ await mock_app.on_websocket_data_received(
+ mock_client,
+ {"type": "lxm.ingest_uri", "uri": uri},
+ )
+ await asyncio.sleep(0)
+
+ loop.run_until_complete(_run())
+ finally:
+ loop.close()
+
+ mock_client.send_str.assert_called()
+ raw = mock_client.send_str.call_args[0][0]
+ try:
+ payload = json.loads(raw)
+ except json.JSONDecodeError:
+ return
+ assert payload["type"] == "lxm.ingest_uri.result"
+ assert payload["status"] == "success"
+ assert payload["ingest_type"] == "docs_view"
+
+
def test_telemetry_pack_location_xss_like_strings_return_none():
from meshchatx.src.backend.telemetry_utils import Telemeter
diff --git a/tests/backend/test_interface_options.py b/tests/backend/test_interface_options.py
index a03a51fc..186e021c 100644
--- a/tests/backend/test_interface_options.py
+++ b/tests/backend/test_interface_options.py
@@ -412,6 +412,27 @@ async def test_rnode_persists_flow_control_and_id_callsign(temp_dir):
assert saved["airtime_limit_short"] == 33.0
+@pytest.mark.asyncio
+async def test_rnode_ble_uart_port_persisted(temp_dir):
+ config = ConfigDict({"reticulum": {}, "interfaces": {}})
+
+ async with make_app(temp_dir, config) as handler:
+ payload = {
+ "name": "RadioBLE",
+ "type": "RNodeInterface",
+ "port": "ble://aa:bb:cc:dd:ee:ff",
+ "frequency": 868000000,
+ "bandwidth": 125000,
+ "txpower": 7,
+ "spreadingfactor": 8,
+ "codingrate": 5,
+ }
+ response = await handler(make_request(payload))
+ body = json.loads(response.body)
+ assert response.status == 200, body
+ assert config["interfaces"]["RadioBLE"]["port"] == "ble://aa:bb:cc:dd:ee:ff"
+
+
@pytest.mark.asyncio
async def test_rnode_frequency_mhz_decimal_normalized_to_hz(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
diff --git a/tests/frontend/archivesPage.security.test.js b/tests/frontend/archivesPage.security.test.js
new file mode 100644
index 00000000..6fee4e90
--- /dev/null
+++ b/tests/frontend/archivesPage.security.test.js
@@ -0,0 +1,138 @@
+// SPDX-License-Identifier: 0BSD
+
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi } from "vitest";
+import ArchivesPage from "@/components/archives/ArchivesPage.vue";
+
+function mountArchives() {
+ const routerPush = vi.fn();
+ return {
+ wrapper: mount(ArchivesPage, {
+ global: {
+ mocks: {
+ $t: (key) => key,
+ $router: { push: routerPush },
+ },
+ stubs: {
+ MaterialDesignIcon: true,
+ ArchiveSidebar: true,
+ },
+ },
+ }),
+ routerPush,
+ };
+}
+
+function randText(len) {
+ const alphabet = "abc<>\"'`\\/\u0000\n\r`topic_id=";
+ let s = "";
+ for (let i = 0; i < len; i++) {
+ s += alphabet[(Math.random() * alphabet.length) | 0];
+ }
+ return s;
+}
+
+describe("Archives page viewing-archive surface (security / fuzz)", () => {
+ const nastyPaths = [
+ "/page/article.mu`topic_id=40",
+ "/page/article.mu`topic_id=40`extra",
+ "/forum/thread.mu`sort=hot",
+ "/../../../etc/passwd",
+ "javascript:alert(1)",
+ "<script>alert(1)</script>",
+ "a".repeat(6000),
+ ];
+
+ const nastyContents = [
+ "<img src=x onerror=alert(1)>",
+ "<svg/onload=alert(1)>",
+ "`>>{{constructor.constructor('return this')()}}",
+ "# Title\n[link](javascript:alert(1))",
+ "\x00".repeat(20),
+ ];
+
+ it("renderFullContent never throws; returns a string for fuzzed paths and bodies", () => {
+ const { wrapper } = mountArchives();
+ for (let i = 0; i < 90; i++) {
+ const page_path = nastyPaths[i % nastyPaths.length] + randText(i % 7);
+ const content = nastyContents[i % nastyContents.length] + randText(40);
+ const archive = {
+ page_path,
+ content,
+ destination_hash: "a".repeat(64),
+ hash: "b".repeat(64),
+ id: i + 1,
+ };
+ expect(() => wrapper.vm.renderFullContent(archive)).not.toThrow();
+ const out = wrapper.vm.renderFullContent(archive);
+ expect(typeof out).toBe("string");
+ }
+ });
+
+ it("archiveViewerClasses stays an array for adversarial page_path values", () => {
+ const { wrapper } = mountArchives();
+ for (const page_path of nastyPaths) {
+ wrapper.vm.viewingArchive = { page_path };
+ expect(Array.isArray(wrapper.vm.archiveViewerClasses)).toBe(true);
+ }
+ wrapper.vm.viewingArchive = null;
+ });
+
+ it("openInNomadnet uses router.push with nomadnetwork route and query only", () => {
+ const { wrapper, routerPush } = mountArchives();
+ wrapper.vm.openInNomadnet({
+ id: 40,
+ destination_hash: "deadbeef",
+ page_path: "/page/article.mu`topic_id=40",
+ });
+ expect(routerPush).toHaveBeenCalledWith({
+ name: "nomadnetwork",
+ params: { destinationHash: "deadbeef" },
+ query: {
+ path: "/page/article.mu`topic_id=40",
+ archive_id: 40,
+ },
+ });
+ });
+
+ it("muExportBasename neutralizes path separators in the basename", () => {
+ const { wrapper } = mountArchives();
+ const base = wrapper.vm.muExportBasename({
+ page_path: "../../../secret/x.mu",
+ hash: "abc",
+ });
+ expect(base.includes("/")).toBe(false);
+ expect(base.includes("..")).toBe(false);
+ });
+
+ it("onArchiveContentClick handles nomadnet links and fragment anchors without throwing", () => {
+ const { wrapper, routerPush } = mountArchives();
+ const holder = document.createElement("div");
+ holder.innerHTML =
+ '<a class="nomadnet-link" data-nomadnet-url="abc123:/p.mu`q=1">n</a>' + '<a href="#frag%20ment">f</a>';
+ document.body.appendChild(holder);
+ try {
+ const nomadA = holder.querySelector("a.nomadnet-link");
+ const fragA = holder.querySelector('a[href^="#"]');
+ const clickOn = (el) => {
+ const ev = new MouseEvent("click", { bubbles: true });
+ Object.defineProperty(ev, "target", { value: el });
+ wrapper.vm.onArchiveContentClick(ev);
+ };
+ clickOn(nomadA);
+ expect(routerPush).toHaveBeenCalledWith({
+ name: "nomadnetwork",
+ params: { destinationHash: "abc123" },
+ query: { path: "/p.mu`q=1" },
+ });
+ routerPush.mockClear();
+ clickOn(fragA);
+ } finally {
+ document.body.removeChild(holder);
+ }
+ const noop = document.createElement("div");
+ const noopEv = new MouseEvent("click");
+ Object.defineProperty(noopEv, "target", { value: noop });
+ expect(() => wrapper.vm.onArchiveContentClick(noopEv)).not.toThrow();
+ });
+});
diff --git a/tests/frontend/deepLinks.docs.security.test.js b/tests/frontend/deepLinks.docs.security.test.js
new file mode 100644
index 00000000..be302436
--- /dev/null
+++ b/tests/frontend/deepLinks.docs.security.test.js
@@ -0,0 +1,112 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import App from "../../meshchatx/src/frontend/components/App.vue";
+import WebSocketConnection from "../../meshchatx/src/frontend/js/WebSocketConnection";
+import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ info: vi.fn(),
+ warning: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
+ default: {
+ send: vi.fn(),
+ connect: vi.fn(),
+ on: vi.fn(),
+ off: vi.fn(),
+ destroy: vi.fn(),
+ },
+}));
+
+describe("meshchatx://docs deep links (security / fuzz)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("routes only hostname docs, not docs- prefix spoof", () => {
+ const push = vi.fn();
+ App.methods.handleProtocolLink.call({ $router: { push } }, "meshchatx://docs-foo?reticulum=evil");
+ expect(push).not.toHaveBeenCalled();
+ });
+
+ it("accepts meshchat alias and path-style manual path", () => {
+ const push = vi.fn();
+ App.methods.handleProtocolLink.call({ $router: { push } }, "meshchat://docs/manual/interfaces.html");
+ expect(push).toHaveBeenCalledWith({
+ name: "documentation",
+ query: { reticulum: encodeURIComponent("manual/interfaces.html") },
+ });
+ });
+
+ it("passes XSS-shaped reticulum through encodeURIComponent only (opaque to router)", () => {
+ const push = vi.fn();
+ const malicious = "<script>alert(1)</script>";
+ App.methods.handleProtocolLink.call(
+ { $router: { push } },
+ `meshchatx://docs?reticulum=${encodeURIComponent(malicious)}`
+ );
+ expect(push).toHaveBeenCalledWith({
+ name: "documentation",
+ query: { reticulum: encodeURIComponent(malicious) },
+ });
+ });
+
+ it("does not treat javascript: prefix as docs link", () => {
+ const push = vi.fn();
+ App.methods.handleProtocolLink.call({ $router: { push } }, "javascript:meshchatx://docs?reticulum=x");
+ expect(push).not.toHaveBeenCalledWith(expect.objectContaining({ name: "documentation" }));
+ });
+
+ it("fuzz random query tails without throwing", () => {
+ const push = vi.fn();
+ for (let i = 0; i < 40; i++) {
+ const tail = `x=${encodeURIComponent(`${i}\u0000<script>`)}`;
+ expect(() =>
+ App.methods.handleProtocolLink.call({ $router: { push } }, `meshchatx://docs?${tail}`)
+ ).not.toThrow();
+ }
+ });
+
+ it("onWebsocketMessage docs_view navigates like handleProtocolLink", async () => {
+ const push = vi.fn().mockResolvedValue(undefined);
+ await App.methods.onWebsocketMessage.call(
+ { $router: { push } },
+ {
+ data: JSON.stringify({
+ type: "lxm.ingest_uri.result",
+ status: "success",
+ ingest_type: "docs_view",
+ message: "Opening documentation.",
+ docs_query: { reticulum: "manual/interfaces.html#x" },
+ }),
+ }
+ );
+ expect(push).toHaveBeenCalledWith({
+ name: "documentation",
+ query: { reticulum: encodeURIComponent("manual/interfaces.html#x") },
+ });
+ expect(ToastUtils.info).toHaveBeenCalled();
+ });
+
+ it("onWebsocketMessage docs_view without docs_query opens documentation index", async () => {
+ const push = vi.fn().mockResolvedValue(undefined);
+ await App.methods.onWebsocketMessage.call(
+ { $router: { push } },
+ {
+ data: JSON.stringify({
+ type: "lxm.ingest_uri.result",
+ status: "success",
+ ingest_type: "docs_view",
+ message: "Opening documentation.",
+ }),
+ }
+ );
+ expect(push).toHaveBeenCalledWith({ name: "documentation" });
+ });
+});
diff --git a/tests/frontend/deepLinks.protocol.security.test.js b/tests/frontend/deepLinks.protocol.security.test.js
index d909d01e..187db551 100644
--- a/tests/frontend/deepLinks.protocol.security.test.js
+++ b/tests/frontend/deepLinks.protocol.security.test.js
@@ -29,6 +29,32 @@ describe("App.vue deep link protocol handling (security-oriented)", () => {
vi.clearAllMocks();
});
+ it("routes meshchatx://docs with reticulum query to documentation page", () => {
+ const push = vi.fn();
+ App.methods.handleProtocolLink.call(
+ { $router: { push } },
+ "meshchatx://docs?reticulum=" + encodeURIComponent("manual/interfaces.html#foo")
+ );
+ expect(push).toHaveBeenCalledWith({
+ name: "documentation",
+ query: {
+ reticulum: encodeURIComponent("manual/interfaces.html#foo"),
+ },
+ });
+ push.mockClear();
+ App.methods.handleProtocolLink.call({ $router: { push } }, "meshchat://docs?path=manual/index.html");
+ expect(push).toHaveBeenCalledWith({
+ name: "documentation",
+ query: { reticulum: encodeURIComponent("manual/index.html") },
+ });
+ });
+
+ it("routes meshchatx://docs without query to documentation index", () => {
+ const push = vi.fn();
+ App.methods.handleProtocolLink.call({ $router: { push } }, "meshchatx://docs");
+ expect(push).toHaveBeenCalledWith({ name: "documentation" });
+ });
+
it("sends map deep links to lxm.ingest_uri unchanged over WebSocket", () => {
const uri = "meshchatx://map?lat=1&lon=2&z=4&label=" + encodeURIComponent("<img src=x onerror=alert(1)>");
App.methods.handleProtocolLink.call({ $router: { push: vi.fn() } }, uri);
diff --git a/tests/frontend/reticulumDocsNavigation.test.js b/tests/frontend/reticulumDocsNavigation.test.js
new file mode 100644
index 00000000..eb73cc95
--- /dev/null
+++ b/tests/frontend/reticulumDocsNavigation.test.js
@@ -0,0 +1,19 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect } from "vitest";
+import { bundledReticulumManualDeepLink } from "../../meshchatx/src/frontend/js/reticulumDocsNavigation.js";
+
+describe("bundledReticulumManualDeepLink", () => {
+ it("builds meshchatx://docs with reticulum query", () => {
+ const u = bundledReticulumManualDeepLink("manual/interfaces.html#common-interface-options");
+ expect(u.startsWith("meshchatx://docs?")).toBe(true);
+ const parsed = new URL(u.replace(/^meshchatx:/, "https:"));
+ expect(parsed.searchParams.get("reticulum")).toBe("manual/interfaces.html#common-interface-options");
+ });
+
+ it("supports meshchat scheme alias", () => {
+ expect(bundledReticulumManualDeepLink("manual/index.html", "meshchat")).toMatch(
+ /^meshchat:\/\/docs\?reticulum=/
+ );
+ });
+});
diff --git a/tests/frontend/reticulumPathfinding.test.js b/tests/frontend/reticulumPathfinding.test.js
index 21514bc7..68361cac 100644
--- a/tests/frontend/reticulumPathfinding.test.js
+++ b/tests/frontend/reticulumPathfinding.test.js
@@ -1,3 +1,5 @@
+// SPDX-License-Identifier: 0BSD
+
import { describe, it, expect, vi } from "vitest";
import {
getDestinationPath,
@@ -6,76 +8,38 @@ import {
runDestinationPathFinder,
} from "../../meshchatx/src/frontend/js/reticulumPathfinding.js";
-describe("reticulumPathfinding", () => {
- it("getDestinationPath uses destination path API", async () => {
+describe("reticulumPathfinding.js", () => {
+ it("getDestinationPath builds expected URL and forwards params", () => {
const api = { get: vi.fn().mockResolvedValue({ data: { path: null } }) };
- await getDestinationPath(api, "abcd", { request: "1", timeout: 4 });
- expect(api.get).toHaveBeenCalledWith("/api/v1/destination/abcd/path", {
- params: { request: "1", timeout: 4 },
+ getDestinationPath(api, "deadbeef", { request: true, timeout: 12 });
+ expect(api.get).toHaveBeenCalledWith("/api/v1/destination/deadbeef/path", {
+ params: { request: "1", timeout: 12 },
});
});
- it("coerces request true to string", async () => {
+ it("getDestinationPath uses raw hash segment (caller must validate)", () => {
const api = { get: vi.fn().mockResolvedValue({ data: {} }) };
- await getDestinationPath(api, "h1", { request: true });
- expect(api.get).toHaveBeenCalledWith("/api/v1/destination/h1/path", {
- params: { request: "1" },
- });
- });
-
- it("coerces request false to string", async () => {
- const api = { get: vi.fn().mockResolvedValue({ data: {} }) };
- await getDestinationPath(api, "h2", { request: false });
- expect(api.get).toHaveBeenCalledWith("/api/v1/destination/h2/path", {
- params: { request: "0" },
- });
- });
-
- it("postRequestPath and postDropPath hit expected routes", async () => {
- const api = { post: vi.fn().mockResolvedValue({ data: {} }) };
- await postRequestPath(api, "aaaabbbbccccddddeeeeffffaaaabbbb");
- expect(api.post).toHaveBeenCalledWith("/api/v1/destination/aaaabbbbccccddddeeeeffffaaaabbbb/request-path");
- await postDropPath(api, "x");
- expect(api.post).toHaveBeenCalledWith("/api/v1/destination/x/drop-path");
- });
-
- it("runDestinationPathFinder quick posts request-path", async () => {
- const api = { post: vi.fn().mockResolvedValue({ data: {} }) };
- const r = await runDestinationPathFinder(api, "q1", "quick");
- expect(r.ok).toBe(true);
- expect(api.post).toHaveBeenCalledWith("/api/v1/destination/q1/request-path");
- });
-
- it("runDestinationPathFinder force uses GET with wait", async () => {
- const api = {
- get: vi.fn().mockResolvedValue({ data: { path: { hops: 1 } } }),
- };
- const r = await runDestinationPathFinder(api, "f1", "force", { forceTimeout: 9 });
- expect(r.path.hops).toBe(1);
- expect(api.get).toHaveBeenCalledWith("/api/v1/destination/f1/path", {
- params: { request: "1", timeout: 9 },
- });
+ getDestinationPath(api, "not/a/hex", {});
+ expect(api.get).toHaveBeenCalledWith("/api/v1/destination/not/a/hex/path", expect.any(Object));
});
- it("runDestinationPathFinder drop_then_request drops then posts", async () => {
- const api = { post: vi.fn().mockResolvedValue({ data: {} }) };
- await runDestinationPathFinder(api, "d1", "drop_then_request");
- expect(api.post).toHaveBeenNthCalledWith(1, "/api/v1/destination/d1/drop-path");
- expect(api.post).toHaveBeenNthCalledWith(2, "/api/v1/destination/d1/request-path");
+ it("postRequestPath and postDropPath hit destination endpoints", () => {
+ const api = { post: vi.fn().mockResolvedValue({}) };
+ postRequestPath(api, "ab");
+ postDropPath(api, "ab");
+ expect(api.post).toHaveBeenCalledWith("/api/v1/destination/ab/request-path");
+ expect(api.post).toHaveBeenCalledWith("/api/v1/destination/ab/drop-path");
});
- it("runDestinationPathFinder drop_then_request continues if drop fails with handler", async () => {
- const onDrop = vi.fn();
- const api = {
- post: vi.fn().mockRejectedValueOnce(new Error("no drop")).mockResolvedValue({ data: {} }),
- };
- await runDestinationPathFinder(api, "d2", "drop_then_request", { onDropPathError: onDrop });
- expect(onDrop).toHaveBeenCalled();
- expect(api.post).toHaveBeenLastCalledWith("/api/v1/destination/d2/request-path");
+ it("runDestinationPathFinder quick only posts request-path", async () => {
+ const api = { post: vi.fn().mockResolvedValue({}) };
+ const r = await runDestinationPathFinder(api, "h", "quick");
+ expect(r).toEqual({ ok: true, path: null });
+ expect(api.post).toHaveBeenCalledTimes(1);
});
it("runDestinationPathFinder rejects unknown mode", async () => {
const api = { get: vi.fn(), post: vi.fn() };
- await expect(runDestinationPathFinder(api, "z", "invalid")).rejects.toThrow("unknown path finder mode");
+ await expect(runDestinationPathFinder(api, "h", "invalid-mode")).rejects.toThrow("unknown path finder mode");
});
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────